昨天,我們已經學會怎麼看一張 State Diagram(狀態圖)了。
今天就直接把它放回 BuJo,一起來看看圖上的 State 和 Transition,是怎麼一路走進 Code 裡的吧!

BuJo 的 Activity 主要會在四個 State 之間移動:
recruiting → 揪團中
voting → 建立者決選中
confirmed → 已成團
cancelled → 已取消
活動建立後會先進入 recruiting,接著依照截止時間、報名結果和建立者的操作,走向不同 State。
例如報名截止,而且符合繼續進入決選的條件:
recruiting
↓
voting
之後建立者確認成團,就會再走向:
voting
↓
confirmed
但如果報名截止後不符合繼續條件,也可能直接走向 cancelled;在符合規則的情況下,建立者也可以從 recruiting 直接確認成團。
昨天看這張圖時,我們主要在理解:
「現在有哪些 State?它們之間可以怎麼移動?」
但真的要把這些箭頭寫進程式,還得再把每一條 Transition 的規則拆得更清楚。
這時候就會用到 Transition Table(狀態轉換表)。
State Diagram 很適合先看整個流程,但真的準備寫 Code 時,一條:
recruiting → voting
還是太簡略了。
因為程式除了要知道「可以從哪裡走到哪裡」,還需要知道:
什麼事情發生、哪些條件成立時,這條 Transition 才可以走。
這時候就可以把 State Diagram 整理成 Transition Table(狀態轉換表):
| Current State | Trigger(觸發) | Guard / Condition(條件) | Next State |
|---|---|---|---|
recruiting |
報名截止 | 人數已達標,可以進入決選 | voting |
recruiting |
報名截止 | 人數未達標 | cancelled |
recruiting |
建立者提前確認成團 | 確認時段未過期 | confirmed |
voting |
建立者確認成團 | 確認時段未過期 | confirmed |
voting |
決策截止 | 仍未確認成團 | cancelled |
recruiting / voting |
建立者手動取消 | 目前仍是可取消的進行中 State | cancelled |
像圖上的:
recruiting → voting
現在就可以讀成:
Current State:recruiting
Trigger:報名截止
Guard / Condition:已達標
Next State:voting
所以:
State Diagram → 負責先讓我們看懂整體流程
Transition Table → 則把每一條箭頭的規則攤開。
有些比較細的 Guard 不一定會全部塞進圖裡,例如確認成團時還要檢查時段是否已經過期,就可以留到 Table 或 Code 再補完整。
State Machine 不只規定下一個 State 可以走去哪裡,也會限制目前可以做哪些事。
例如 BuJo 只有在 recruiting(揪團中)時可以報名。
一旦活動進入 voting、confirmed 或 cancelled,Backend 就會擋下新的報名。
回到 joinActivity(),先看一般新報名的這個判斷:
// 一般新報名:目前不是 recruiting,就直接拒絕
if (!isResubmission && activity.status !== "recruiting") {
return {
status: 400,
message: req.t("activity.notRecruiting"),
};
}
所以 BuJo 在這裡守住的 State Rule 很單純:
recruiting
→ 可以報名
voting / confirmed / cancelled
→ 不能報名
也就是說,State 不只是拿來顯示活動目前走到哪裡,也能成為 Backend 判斷一個 Action 是否合法的依據。
recruiting → voting 一路追進 Code接下來就挑 State Diagram 裡的一條 Transition,看看它真正寫進程式後會變成什麼樣子。

圖上的規則是:
recruiting
↓ 報名截止(達標)
voting
如果把這條箭頭拆成程式需要知道的資訊,就是:
Current State:recruiting
Trigger:到達 vote_deadline_at
Guard / Condition:符合繼續進入決選的條件
Next State:voting
其中 vote_deadline_at,就是 BuJo 現在用來表示報名截止時間的欄位。
回到 getActivity(),這幾段 Code 剛好可以一路對上剛才的規則。
以下節錄和這次 Transition 有關的部分:
// 1. 先取得目前 State 與報名截止時間
let currentStatus = activity.status;
const recruitingDeadline = sched?.vote_deadline_at;
// 2. 只有 recruiting,而且報名截止時間已到,才開始處理 Transition
if (
currentStatus === "recruiting" &&
sched &&
now >= recruitingDeadline
) {
const target = activity.participant_target;
let nextStatus;
// 3. 有設定目標人數,但人數未達標 → cancelled
if (target && joinedCount < target) {
nextStatus = "cancelled";
// 4. Range Mode 沒有人提交可用時間 → cancelled
} else if (
isRangeMode &&
!target &&
(activity.availabilityRanges ?? []).filter(
(r) => r.user_id !== activity.creator_id
).length === 0
) {
nextStatus = "cancelled";
// 5. 沒有落入取消條件 → voting
} else {
nextStatus = "voting";
}
// 6. 把新的 State 寫回 Activity
await prisma.activity.updateMany({
where: {
id: activity.id,
status: "recruiting",
},
data: {
status: nextStatus,
},
});
}
這樣重新看,就會發現 State Diagram 裡的元素其實都找得到對應位置:
Current State
→ currentStatus === "recruiting"
Trigger
→ now >= recruitingDeadline
Guard / Condition
→ 是否落入取消條件
Next State
→ nextStatus = "voting" / "cancelled"
原本圖上只有一條:
recruiting → voting
到了 Code 裡,就變成一組真正會被程式執行的條件。
Code 寫好之後,還需要確認:
符合這些條件時,Activity 真的會從 recruiting 走到 voting 嗎?
這時候就回到 Day15 看過的 Test。
BuJo 的 State Machine Test 裡,就有直接驗證這件事。這裡先只看最後的 Assert:
// 預期 Activity 從 recruiting 更新成 voting
expect(prisma.activity.updateMany).toHaveBeenCalledWith({
where: {
id: ACTIVITY_ID,
status: "recruiting",
},
data: {
status: "voting",
},
});
這個 Assert 驗證的,就是圖上那條 recruiting → voting 有沒有真的發生。
所以我們前面一路走過來的:
State Diagram
↓
Rule
↓
Code
↓
Test
到了這裡才真正串起來。
圖先定義「應該怎麼走」,Code 把規則實作出來,Test 再確認這條 Transition 真的沒有走歪。
status前面的 recruiting → voting,最明顯的變化就是 Activity 的 status。
但有些 Transition 發生時,除了 State 改變,和這個 State 有關的資料也要一起更新。
BuJo 的「確認成團」就是一個例子。
當建立者確認成團時,程式會先檢查目前是不是允許確認的 State;確認成功後,再把 Activity 改成 confirmed,同時記住最後確認的是哪一個時段。
以下只節錄這次要看的部分:
// 1. 只有 recruiting 或 voting 可以確認成團
if (
activity.status !== "recruiting" &&
activity.status !== "voting"
) {
return res.status(400).json({
message: req.t("activity.formationNotAllowedInState"),
});
}
// ...
// 2. 確認成功後,Activity 進入 confirmed
await tx.activity.updateMany({
where: {
id,
status: activity.status,
},
data: {
status: "confirmed",
},
});
// 3. 同時記住最後確認的時段
await tx.activitySchedule.update({
where: {
activity_id: id,
},
data: {
confirmed_slot_id: confirmedSlotId,
},
});
把這幾段放在一起看就很清楚了。
目前只有:
recruiting
或
voting
可以進行確認成團。
成功之後,一方面:
Activity.status
→ confirmed
另一方面:
ActivitySchedule.confirmed_slot_id
→ 最後確認的時段
所以一次 State Transition,不一定只是:
status A → status B
它代表的是系統進入了一個新的狀態。
如果這個新狀態還需要留下其他 Domain Data(領域資料),那些資料也可能要在同一個流程裡一起更新。
那狀態機的分享就到這裡啦!
我真心覺得跟 State Machine 是相見恨晚啊~~~
它完美拯救了當初被各種邊界條件搞得很混亂的我。
而且這些條件判斷,也不是簡單丟一兩句話給 AI,就能保證它幫你全部想清楚的。
所以對我來說,State Machine 真的是一個很好用的視覺化工具。
它可以把原本散在 Code 和腦袋裡的流程整理出來,不管是自己寫程式,還是跟夥伴討論功能,都比較不會再搞不清楚「我們現在到底走到哪裡啦~~~」
不過今天我們都先假設:
一次只有一個 Request 在改 State。
那如果兩個 Request 幾乎同時進來,都想把同一個 recruiting 改成下一個 State,會發生什麼事?
明天就從今天看過的 updateMany + where status 繼續往下拆,來認識 Optimistic Lock(樂觀鎖)!